멱등적인 HTTP 메서드가 재시도에 중요한 이유

멱등적인 HTTP 메서드가 재시도에 중요한 이유

한눈에 보기

GET과 PUT, DELETE는 의미상 멱등적이어야 한다. POST는 기본적으로 그렇지 않지만 idempotency key와 결과 저장으로 특정 작업을 멱등하게 만들 수 있다.

목차

왜 이 문제가 생기는가

클라이언트가 결제 요청을 보낸 뒤 응답을 받기 전에 네트워크가 끊겼다고 하자. 화면에는 실패가 표시되지만 서버가 결제를 처리했는지는 알 수 없다. 사용자가 다시 버튼을 누르거나 클라이언트가 자동 재시도하면 결제가 두 번 생성될 수 있다.

분산 환경에서는 “요청이 실패했다”와 “서버 작업이 실행되지 않았다”가 같은 말이 아니다. 멱등성은 이런 불확실성 속에서 같은 의도의 요청을 반복해도 최종 상태가 중복되지 않도록 만드는 핵심 성질이다.

이 글의 예제에 관하여

주문과 결제 코드는 멱등 처리 구조를 설명하기 위한 가상 예제다. 실제 결제사 연동이나 프로젝트 코드를 복사하지 않았다.

멱등성은 응답이 같다는 뜻이 아니다

연산이 멱등하다는 것은 같은 연산을 여러 번 적용한 최종 상태가 한 번 적용한 상태와 같다는 뜻이다.

f(f(x)) = f(x)

예를 들어 사용자 42의 표시 이름을 Mina설정하는 요청을 여러 번 보내도 최종 이름은 Mina다.

PUT /profiles/42 HTTP/1.1
Content-Type: application/json

{"displayName":"Mina"}

반대로 잔액에 1,000원을 추가하는 요청은 반복할수록 상태가 달라진다.

POST /wallets/42/credits HTTP/1.1
Content-Type: application/json

{"amount":1000}

멱등하더라도 매번 응답 코드와 로그까지 같아야 하는 것은 아니다. 첫 DELETE204, 이미 사라진 리소스에 대한 두 번째 DELETE404를 줄 수 있지만 서버의 최종 리소스 상태는 “없음”으로 같다.

HTTP 메서드별 의미

HTTP 메서드 자체가 구현을 자동으로 안전하게 만들지는 않지만 클라이언트와 중간 계층이 기대할 수 있는 의미를 제공한다.

메서드 의미상 멱등성
GET, HEAD O 리소스를 조회한다
PUT O 지정 URI의 표현을 교체·설정한다
DELETE O 지정 리소스가 없는 상태로 만든다
POST 기본적으로 X 새 처리나 하위 리소스를 생성한다
PATCH 패치 의미에 따라 다름 값을 설정할 수도, 증가시킬 수도 있다

다음 두 PATCH는 성격이 다르다.

{ "operation": "set", "status": "archived" }
{ "operation": "increment", "viewCount": 1 }

첫 연산은 같은 상태를 설정하도록 만들 수 있지만 두 번째는 반복할 때마다 값이 증가한다. 메서드 이름보다 실제 도메인 연산을 봐야 한다.

GET에 상태 변경을 숨기지 않는다

링크 미리보기, 검색 봇, 브라우저 재요청은 GET을 반복할 수 있다. GET /orders/42/cancel처럼 상태 변경을 GET으로 구현하면 사용자의 명시적 의도 없이 작업이 실행될 수 있다.

응답을 잃어버린 결제 요청

불확실한 실패는 다음 순서로 발생한다.

sequenceDiagram
    participant C as Client
    participant A as Payment API
    participant D as Database

    C->>A: POST /payments
    A->>D: 결제 레코드 생성
    D-->>A: commit 성공
    A--xC: 201 응답이 네트워크에서 유실
    Note over C: 처리 여부를 알 수 없음
    C->>A: 같은 의도의 POST 재시도

클라이언트가 받은 정보만으로 첫 요청이 실행되지 않았다고 단정할 수 없다. timeout, 연결 종료, gateway 502 모두 하위 서버의 처리 결과와 정확히 일치하지 않을 수 있다.

POST를 idempotency key로 보호하기

생성 작업인 POST도 클라이언트가 같은 논리 요청에 같은 idempotency key를 보내고 서버가 처리 결과를 기억하면 중복을 방지할 수 있다.

POST /payments HTTP/1.1
Content-Type: application/json
Idempotency-Key: 7fc2a4a2-0b94-47a9-ae43-example

{"orderId":"order-501","amount":32000,"currency":"KRW"}

재시도할 때는 새 키를 만들지 않고 같은 키를 사용한다.

async function createPayment(command) {
  const key = crypto.randomUUID();

  return retryWithBackoff(() =>
    fetch("/payments", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Idempotency-Key": key,
      },
      body: JSON.stringify(command),
    }),
  );
}

사용자가 별개의 결제를 새로 시작했다면 새 키를 발급한다. 키의 범위는 사용자 또는 가맹점 같은 주체와 함께 묶어, 다른 사용자가 우연히 같은 문자열을 사용해도 충돌하지 않게 한다.

서버에서 중복 요청을 처리하는 방법

서버는 키만 저장해서는 부족하다. 같은 키가 어떤 요청을 의미했는지, 현재 처리 상태와 반환할 결과가 무엇인지 관리해야 한다.

CREATE TABLE idempotency_requests (
  owner_id       BIGINT       NOT NULL,
  request_key    VARCHAR(80)  NOT NULL,
  request_hash   CHAR(64)     NOT NULL,
  status         VARCHAR(16)  NOT NULL,
  response_code  INT          NULL,
  response_body  JSON         NULL,
  expires_at     DATETIME     NOT NULL,
  PRIMARY KEY (owner_id, request_key)
);

처리 흐름은 다음과 같다.

  1. 인증된 주체와 idempotency key로 기존 레코드를 찾는다.
  2. 없으면 processing 레코드를 원자적으로 만든다.
  3. 같은 키와 같은 요청 hash가 완료 상태라면 저장한 결과를 반환한다.
  4. 같은 키인데 요청 내용이 다르면 충돌 오류를 반환한다.
  5. 처리 중이라면 기다리거나 명시적인 진행 중 응답을 준다.
type BeginResult =
  | { kind: "acquired" }
  | { kind: "completed"; status: number; body: unknown }
  | { kind: "in-progress" }
  | { kind: "conflict" };

async function beginIdempotentRequest(
  ownerId: string,
  key: string,
  requestHash: string,
): Promise<BeginResult> {
  // unique constraint와 transaction을 이용하는 저장 계층에 위임한다.
  return idempotencyRepository.begin({ ownerId, key, requestHash });
}

request hash를 비교하지 않으면 클라이언트가 같은 키로 금액만 바꿨을 때 이전 결과를 돌려주는 위험이 있다. 키는 요청 본문을 대신하는 것이 아니라 동일 의도를 식별하는 수단이다.

동시 요청과 원자성

다음 구현은 조회와 생성 사이에 경쟁 조건이 있다.

// 좋지 않은 예
const existing = await repository.find(ownerId, key);
if (!existing) {
  await repository.insert({ ownerId, key, status: "processing" });
}

두 요청이 동시에 find를 통과하면 둘 다 없다고 판단할 수 있다. (owner_id, request_key) unique constraint와 원자적인 insert 또는 transaction으로 승자를 하나만 만들어야 한다. 데이터베이스 제약은 마지막 방어선이다.

결제 레코드 생성과 멱등 결과 저장 사이의 실패도 생각해야 한다. 같은 데이터베이스에서 처리할 수 있다면 하나의 트랜잭션으로 묶는다. 외부 결제사 호출처럼 단일 트랜잭션으로 묶을 수 없다면 외부 시스템에도 안정적인 요청 식별자를 전달하고, 재조정 작업으로 중간 상태를 복구한다.

재시도 정책까지 함께 설계하기

멱등한 작업이라고 즉시 무한 재시도해도 되는 것은 아니다. 과부하 상태의 서버에 동시 재시도가 몰리면 장애를 키운다.

function retryDelay(attempt) {
  const base = Math.min(500 * 2 ** attempt, 8_000);
  const jitter = Math.random() * 300;
  return base + jitter;
}

자동 재시도는 timeout, 일부 5xx, 429처럼 일시적일 가능성이 있는 실패에 제한하고 Retry-After가 있으면 존중한다. 검증 실패인 400이나 권한 실패인 403을 같은 요청으로 반복해도 성공 가능성은 낮다.

재시도 횟수보다 전체 deadline을 두는 편이 사용자 경험을 설명하기 쉽다. 각 시도 timeout, 최대 경과 시간, 취소 신호를 함께 정한다.

운영에서 확인할 것

멱등 API 점검

  • 논리적으로 같은 요청이 같은 키를 재사용하는가?
  • 키의 범위와 만료 시간이 정해져 있는가?
  • 같은 키로 다른 본문이 오면 거부하는가?
  • 동시 요청을 unique constraint 또는 원자 연산으로 막는가?
  • 성공 응답이 유실된 상황을 통합 테스트하는가?
  • 로그에서 최초 처리와 재사용 응답을 구분할 수 있는가?

GET과 PUT, DELETE는 의미상 멱등적이어야 한다. POST는 기본적으로 그렇지 않지만 idempotency key와 결과 저장으로 특정 작업을 멱등하게 만들 수 있다.

결론

멱등성은 네트워크 실패 뒤 서버의 처리 여부를 알 수 없을 때 같은 의도를 안전하게 다시 전달하기 위한 계약이다. HTTP 메서드의 의미를 지키고, 중복 위험이 있는 POST에는 요청 내용과 결합된 idempotency key를 사용한다. 서버에서는 unique constraint와 트랜잭션으로 동시 요청을 막고, 클라이언트에서는 backoff와 deadline을 포함한 재시도 정책까지 함께 설계해야 한다.

관련 노트